Alexgogoing

神奇的JS 定时器校准

字数统计: 944阅读时长: 3 min
2021/05/12 Share

[神奇的JS] 定时器校准

在实际编码中,我们总会使用setTimeout和setInterval去创建定时任务,如果对于时间精准度不高,满足业务要求还可,但是对于高时间精度的业务,我们还是需要一个相对精准的时钟。

创建定时器

使用JS中常用的两个方法

1
2
3
4
5
6
7
8
9
10
console.log(performance.now());
let _interval = setInterval(() => {
console.log('interval', performance.now());
//do something
}, 1000)
let _timer = setTimeout(() => {
clearInterval(_interval);
console.log('timer', performance.now());
// do something else
}, 1900)

这就可创建定时任务,我们假设让他在1s后执行,那么,事实上,真的是1s后便会执行任务吗?

image-20210512203037484

任务的开始时间13696978.69,循环定时器执行时间期望应该是13696978.69 + 1000 = 13697978.69,实际为13697980.179,偏差2ms左右,延迟定时器执行时间期望应该是13696978.69 + 1900 = 13698878.69,实际为13698884.665,偏差6ms左右。

定时器偏差

为何出现偏差呢?在解释偏差出现的原因之前,不得不提及js的事件循环(event loop)机制。

在js的单线程执行栈中,任务被划分为同步任务和异步任务,而异步任务又分为微任务和宏任务,同步任务是处在‘第一梯队’中优先顺序执行;微任务处于‘第二梯队’,例如es6中的promise就是微任务的典型代表;最后便是宏任务,setTimeout和setInterval便是处在这个位置。

当我们定义一个定时器时,实际上也只是向事件循环队列的队尾处推送了一个任务而已,除非当前事件队列为空,否则不会立即执行,于是便产生了偏差,而我们也注意到,这个偏差只可能为正数。

校准定时器

知己知彼,百战不die。知道原因,如何校准方法如下:

  1. 防止setInterval的累计误差

这个阶段下,每次执行定时器任务前计算出误差,然后使用setTimeout推送事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let taskId = null;
let count = 0;
let startTime = null;
let duration = 1000;
function task(){
if(!startTime){
startTime = performance.now();
}
let exeTime = performance.now();
let nextTime = startTime + duration * (++count);
console.log('exeTime: ', exeTime);
console.log('nextTime: ', nextTime);
console.log('nextTime - exeTime: ', nextTime - exeTime);
taskId = setTimeout(task, nextTime - exeTime);
}
task.call(this);

这样可避免累计误差,相对校准定时器。

  1. 利用帧同步机制校准定时器

首先,实现一个帧时钟

1
2
3
4
let step = (timestamp, elapsed) => {
requestAnimationFrame(step);
}
requestAnimationFrame(step);

然后,加上任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let duration = 1000;
let lastFrameTimestamp = null;
let step = (timestamp) => {
if(!lastFrameTimestamp){
lastFrameTimestamp = timestamp;
}
if((timestamp - lastFrameTimestamp) >= duration){
lastFrameTimestamp = timestamp;
console.log(timestamp - lastFrameTimestamp);
task();
}
requestAnimationFrame(step);
}
requestAnimationFrame(step);

function task(){
// do something
console.log('execute');
}

image-20210512211458646

反正挺准的。

扩展

提到了事件循环,那么有这样一段代码,他的输出是怎样的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
console.log('aaa');

setTimeout(()=>console.log('t1'), 0);

(async ()=>{
console.log(111);
await console.log(222);
console.log(333);

setTimeout(()=>console.log('t2'), 0);
})().then(()=>{
console.log(444);
});
/** 这样写效果一样
new Promise(async (resolve) => {
console.log(111);
await console.log(222);
// resolve() 放在这个位置也不影响最终结果
console.log(333);
resolve();

setTimeout(() => console.log('t2'), 0);
}).then(() => {
console.log(444);
});
*/

console.log('bbb');

想出结果了吗?

image-20210513103547321

处理Promise的语法糖async和await,async声明微任务,而await是具体的推送微任务,声明微任务的代码在真正推送微任务前是同步的。可以认为await == then

console.log(111) == 同步任务

await console.log(222) == 推送到事件队列一个微任务并且执行同步代码,从这时起微任务开始生效,js进程阻塞执行之后的‘同步代码’

归类:

  1. 同步代码

    console.log(‘aaa’);

    console.log(111);

    await console.log(222);

    console.log(‘bbb’);

  2. 微任务

    console.log(333);

    console.log(444);

  3. 宏任务

    setTimeout(()=>console.log(‘t1’), 0);

    setTimeout(() => console.log(‘t2’), 0);

CATALOG
  1. 1. [神奇的JS] 定时器校准
    1. 1.1. 创建定时器
    2. 1.2. 定时器偏差
    3. 1.3. 校准定时器
    4. 1.4. 扩展